Skip to content

feat(spec): declare metadata.maskObjectFields and getMetadataReadableFields (ADR-0106 follow-through) - #6874

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-6622-adr-0106-declarations
Aug 9, 2026
Merged

feat(spec): declare metadata.maskObjectFields and getMetadataReadableFields (ADR-0106 follow-through)#6874
os-zhuang merged 2 commits into
mainfrom
claude/issue-6622-adr-0106-declarations

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #6622

Part of #3682 (ADR-0106 metadata-plane FLS). PR #6612 shipped two members honoured but undeclared, deliberately: packages/spec is the spec seat's surface, so the implementing PR did not touch it. This is the declaration half, and it closes both in one PR as the card asks.

No runtime behaviour changes, in either direction, in any deployment. Both members are already implemented and already read; what moves is the type surface, the generated reference, and one authoring path that was silently lossy (below).

1. MetadataEndpointsConfigSchema.maskObjectFields (ADR-0106 D8)

maskObjectFields: z.boolean().default(true)

The per-server switch for masking served object schemas to the calling user's readable fields. @objectstack/rest's normalizeConfig has read it since #6612 through (metadata as any).maskObjectFields, following the api.enableOpenApi / api.enableSearch precedent, so a deployment that sets it has always been honoured.

Three things the seat adds, one of which is a real gap rather than ergonomics:

  • The key is type-safe in objectstack.config.ts, carries its documentation with it, and appears in the generated reference (content/docs/references/api/rest-server.mdx, regenerated in commit 2 — generated file, not hand-edited).
  • The opt-out now survives a parse. MetadataEndpointsConfigSchema is a plain z.object, i.e. zod's strip posture: an undeclared key is discarded and the parse still succeeds. Nothing in the runtime parses RestServerConfigSchema today — normalizeConfig receives the config object raw, which is exactly why the cast worked — but an author who ran their own config through the schema before handing it to the server lost maskObjectFields: false silently, with a successful parse and an unmasked deployment. That hole is closed, and it is pinned (maskObjectFields: false survives .parse()).
  • The default is true, matching the shipped behaviour and isObjectSchemaMaskingEnabled's configured === false opt-out semantics, so no deployment moves. The deployment-wide OS_ALLOW_UNMASKED_OBJECT_METADATA escape hatch is unchanged and documented on the key.

2. ISecurityService.getMetadataReadableFields? (ADR-0106 D7)

getMetadataReadableFields?(object: string, context?: SecurityContext): Promise<string[] | undefined>;

The metadata-plane sibling of getReadableFields: identical in every respect but one — a caller resolving to zero permission sets goes through the same fallback-set resolution /auth/me/permissions uses (security.fallbackPermissionSet, default member_default) instead of falling open to the full field set, so a guest-facing deployment's schema exposure is a deliberate permission-set decision rather than an accidental everything-default. @objectstack/plugin-security has implemented it since #6612 and @objectstack/metadata-core's resolveObjectSchemaMaskPosture already feature-detects it, falling back to getReadableFields.

Same shape as loadDiagnosed / getDiagnosed (#4127 batch 4 / #6051): call sites and implementation already agree, only the contract was missing.

Optional, not required — following #6841

#6841 landed checkAuthoredRowWrite? as optional on this same contract three commits before this branch, and recorded the counter-precedent (#6428 declared ISharingService.checkEdit / checkDelete as required members with runtime feature detection). I follow #6841, and the reasons transfer without weakening:

  1. Absence is a defined, handled state, not a defect. A security service predating ADR-0106 keeps its pre-ADR behaviour by design; metadata-core reads the missing method as "use getReadableFields". A required declaration would assert something the ruling explicitly names as legitimate.
  2. Required buys no guarantee here. This contract's own header instructs consumers to feature-detect, and packages/rest types the whole service as Partial<ISecurityService>.
  3. Optional prevents the mistake structurally. svc.getMetadataReadableFields(...) unguarded compiles under a required declaration and throws against any partial implementation; under an optional one it does not compile. There is a @ts-expect-error pin for exactly this.

One asymmetry worth naming, since it is the only place the two cards differ: degrading here is never a narrowing. The fallback (getReadableFields) is equal or wider than the metadata-plane answer, so absence cannot hide a column a caller may see — it can only decline the extra tightening D7 adds for zero-permission-set callers. That is the pre-ADR-0106 behaviour, which is the correct thing for a deployment that never opted into D7 to get.

Strictness-ledger judgement: neither member needs it

The card left this to the spec seat. My call is no ledger treatment, for two independent reasons:

  • ISecurityService is a TypeScript interface, not a zod object. The 未知键静默剥离仍是全仓默认:把 #3405 的 strict 收紧从一个 schema 推广到整个可授权面(ADR-0078 完整性闸门) #4001 ledger classifies object sites by unknown-key posture; there is no site here to classify.
  • maskObjectFields is added inside an existing strip-posture z.object site. The ledger counts sites, not keys, so this PR adds zero sites and shifts no count. Tightening MetadataEndpointsConfigSchema to .strict() would be a separate campaign step with its own compatibility question (it would start rejecting configs that pass extra keys today), and riding it in here would hide a behaviour change inside a declaration PR.

pnpm --filter @objectstack/spec check:strictness-ledger passes unchanged, which is the measurement behind both claims.

The (metadata as any) cast in packages/rest: measured removable, deliberately not removed here

The dispatch asked me to measure this and state a call. Measured, on this branch, with the freshly built spec dist:

- maskObjectFields: isObjectSchemaMaskingEnabled((metadata as any).maskObjectFields),
+ maskObjectFields: isObjectSchemaMaskingEnabled(metadata.maskObjectFields),

tsc --noEmit in packages/rest reports no new error — only the two pre-existing, unrelated src/package-routes.ts string | string[] errors, byte-identical before and after. The cast is genuinely vestigial now. The probe was reverted; git diff packages/rest/ is empty.

Call: out of scope for this PR. The card is claimed with a packages/spec-only file surface, and that surface exists because of same-file serialization in the spec lane (this card already waited on #6841 for one shared file). Reaching into packages/rest/src/rest-server.ts — one of the hottest files in the repo — to delete one cast trades a conflict risk against zero functional gain. The precedent agrees: api.enableOpenApi has had a declared seat in rest-server.zod.ts since long before this PR and normalizeConfig still reads it through a cast, so a leftover cast beside a declared key is an established, harmless state here rather than a defect this PR introduces.

Two follow-up cleanups, both recorded on #6622 rather than done here, and both now purely cosmetic:

  1. packages/rest/src/rest-server.ts — drop the (metadata as any) cast (and, while there, the same for api.enableOpenApi / api.enableSearch), and shorten the comment that explains why the cast exists.
  2. packages/plugins/plugin-security/src/security-plugin.ts — the Object.assign registration comment says the spec seat "is a separate change"; that is now stale prose, and the extension could be folded into the typed securityService literal. The Object.assign itself still type-checks correctly against the new contract either way (verified below).

Reverse verification — the pin can go red

A pin that cannot fail is not a pin. Feeding a wrong-typed implementation to plugin-security's typed securityService literal turns its typecheck red against the freshly built spec dist, which is the check that the contract addition is being read from the new artefact and not a stale one:

src/security-plugin.ts(688,9): error TS2322: Type '(_object: string, _context?: any) => Promise<number[]>'
  is not assignable to type '(object: string, context?: …) => Promise<string[] | undefined>'.
    Type 'Promise<number[]>' is not assignable to type 'Promise<string[] | undefined>'.
      Type 'number[]' is not assignable to type 'string[]'.

The probe was restored byte-identically (confirmed by an empty git diff on the file), and pnpm --filter @objectstack/plugin-security run typecheck is clean again.

The config half carries the same instrument at source level: a @ts-expect-error pin that maskObjectFields: 'false' (a truthy string, the classic config typo) does not compile, so the seat is proven typed rather than any.

Tests

packages/spec/src/api/rest-server.test.ts — 3 cases:

  • the default true materializes through .parse({}) (so a later .optional(), which would hand undefined to the REST layer, fails here instead of silently unmasking every metadata read);
  • maskObjectFields: false survives the parse, and explicit true is a real answer rather than a discarded no-op;
  • compile-time: the key is authorable without a cast, optional on the input side, and boolean-typed (@ts-expect-error on a string).

packages/spec/src/contracts/security-service.test.ts — 2 cases:

  • optionality, the unguarded call not compiling (@ts-expect-error), and the exact prefer-then-fall-back shape metadata-core writes;
  • the semantic difference the second method exists for — the metadata plane narrows for a zero-permission-set caller where the data plane falls open — plus isSystem bypassing on both planes, and the two distinct empty answers (undefined = no answer, [] = disclose nothing) inherited unchanged.

Gates

Every check:* in .github/workflows/lint.yml was enumerated and run one by one, in the foreground, plus check:strictness-ledger from spec-liveness-check.yml. All pass. check:docs failed once, as expected — content/docs/references/api/rest-server.mdx is generated from the spec schemas and goes stale the moment MetadataEndpointsConfigSchema gains a key; regenerated with gen:schema + gen:docs on a clean tree (never in a merge state, never hand-edited) and committed separately.

The spec build regenerated packages/spec/authorable-surface/api.json and authorable-defaults/api.json (one line each: api/MetadataEndpointsConfig:maskObjectFields and … = true). Expected, committed, not reverted.

Consumer sweep — prefix direction, i.e. spec and its dependents, run after the closures were built (turbo run build --filter='./packages/*' --filter='./examples/*^...', then the ledgered --filter='./packages/*' --filter='./packages/*/*', so nothing typechecks against a stale dist): pnpm --filter '...@objectstack/spec' run typecheck over 74 packages — clean. Full test suites for spec and the three packages that actually consume these two surfaces: @objectstack/spec 8908 passed, @objectstack/rest 1109 passed, @objectstack/plugin-security 861 passed, @objectstack/metadata-core 124 passed.

Boundaries honoured

  • No packages/rest or packages/plugins/plugin-security edits — measured, deferred, recorded above.
  • No content/docs/releases/ edits; the changeset (.changeset/adr-0106-declarations.md, @objectstack/spec minor) is this PR's input to the release notes.
  • No ADR edits — ADR-0106 D7/D8 already say what these declarations implement.

Generated by Claude Code

claude added 2 commits August 9, 2026 01:42
…Fields (#6622)

ADR-0106's metadata-plane FLS shipped in #6612 with two members honoured but
undeclared, deliberately: packages/spec is the spec seat's surface. This is the
declaration half — no runtime behaviour changes.

- MetadataEndpointsConfigSchema.maskObjectFields: z.boolean().default(true)
  (ADR-0106 D8). Already read by @objectstack/rest through a cast; the seat
  makes it type-safe in objectstack.config.ts and, unlike before, lets the
  opt-out survive a parse (the schema strips undeclared keys).
- ISecurityService.getMetadataReadableFields? (ADR-0106 D7). Optional, per the
  precedent #6841 set on this contract: absence is a defined state that
  degrades to getReadableFields, and the unguarded call must not compile.

Baselines authorable-surface/api.json and authorable-defaults/api.json are
regenerated by the spec build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01115GjksiLKTEth6gZZMcvG
… key

`content/docs/references/api/rest-server.mdx` is generated from the spec
schemas (`gen:schema` + `gen:docs`); `check:docs` flags it as out of date once
`MetadataEndpointsConfigSchema` gains a key. Regenerated, not hand-edited.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01115GjksiLKTEth6gZZMcvG
@vercel

vercel Bot commented Aug 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 9, 2026 2:11am

Request Review

@github-actions github-actions Bot added the size/m label Aug 9, 2026
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec.

112 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via @objectstack/spec)
  • content/docs/automation/connectors.mdx (via @objectstack/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via packages/spec)
  • content/docs/concepts/north-star.mdx (via @objectstack/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/tenancy-modes.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/data-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/examples.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/spec)
  • content/docs/permissions/authorization.mdx (via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/apps.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/field-grouping-and-order.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 9, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 9, 2026 03:17
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 9, 2026
Merged via the queue into main with commit 8828b9e Aug 9, 2026
27 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-6622-adr-0106-declarations branch August 9, 2026 03:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Declare metadata.maskObjectFields on MetadataEndpointsConfigSchema and getMetadataReadableFields on ISecurityService (ADR-0106 follow-through)

2 participants